D:\a\cssh-rs\cssh-rs\xtask\src\coverage.rs
Line | Count | Source |
1 | | //! Local coverage report generation. |
2 | | //! |
3 | | //! The nightly toolchain is required because `#[coverage(off)]` - used to |
4 | | //! exclude untestable code such as Windows API wrappers and production I/O |
5 | | //! implementations from coverage - relies on the `coverage_attribute` feature, |
6 | | //! which is only available on nightly Rust. Without it the `cfg(coverage_nightly)` |
7 | | //! guards would not activate, causing those impls to be counted as missed lines |
8 | | //! and distorting the report. |
9 | | //! |
10 | | //! The pinned toolchain version is read from |
11 | | //! `.config/coverage/nightly-toolchain.version` and the filename exclusion |
12 | | //! regex is read from `.config/coverage/ignore-filename.regex`; both files |
13 | | //! are shared with the CI workflow to keep the environments in sync. |
14 | | //! |
15 | | //! [`run_coverage`] orchestrates the full workflow: toolchain check, |
16 | | //! instrumented test run, and report generation. |
17 | | |
18 | | use anyhow::{bail, Context, Result}; |
19 | | |
20 | | /// All side-effecting operations required by this module. |
21 | | /// |
22 | | /// Implement with mocks in tests to achieve zero filesystem, process, |
23 | | /// and toolchain side-effects. |
24 | | pub trait CoverageSystem { |
25 | | /// Read the contents of `.config/coverage/nightly-toolchain.version` and |
26 | | /// return the trimmed toolchain identifier (e.g. `nightly-2026-04-20`). |
27 | | /// |
28 | | /// # Errors |
29 | | /// |
30 | | /// Returns an error if the file cannot be read. |
31 | | fn read_nightly_version_file(&self) -> Result<String>; |
32 | | |
33 | | /// Run `rustup toolchain list` and return its stdout. |
34 | | /// |
35 | | /// # Errors |
36 | | /// |
37 | | /// Returns an error if the process cannot be started. |
38 | | fn list_installed_toolchains(&self) -> Result<String>; |
39 | | |
40 | | /// Run `rustup toolchain install <toolchain> --component llvm-tools`. |
41 | | /// |
42 | | /// # Errors |
43 | | /// |
44 | | /// Returns an error if the install fails. |
45 | | fn install_toolchain(&self, toolchain: &str) -> Result<()>; |
46 | | |
47 | | /// Run a `cargo +<toolchain> llvm-cov` subcommand with the given arguments. |
48 | | /// |
49 | | /// # Errors |
50 | | /// |
51 | | /// Returns an error if the command fails. |
52 | | fn run_cargo_llvm_cov(&self, toolchain: &str, args: &[String]) -> Result<()>; |
53 | | |
54 | | /// Read the contents of `.config/coverage/ignore-filename.regex` and |
55 | | /// return the trimmed filename regex pattern passed to |
56 | | /// `--ignore-filename-regex`. |
57 | | /// |
58 | | /// # Errors |
59 | | /// |
60 | | /// Returns an error if the file cannot be read. |
61 | | fn read_ignore_regex_file(&self) -> Result<String>; |
62 | | } |
63 | | |
64 | | /// Production implementation of [`CoverageSystem`]. |
65 | | pub struct RealSystem; |
66 | | |
67 | | #[cfg_attr(coverage_nightly, coverage(off))] |
68 | | impl CoverageSystem for RealSystem { |
69 | | fn read_nightly_version_file(&self) -> Result<String> { |
70 | | std::fs::read_to_string(".config/coverage/nightly-toolchain.version") |
71 | | .context("failed to read .config/coverage/nightly-toolchain.version") |
72 | | .map(|s| s.trim().to_owned()) |
73 | | } |
74 | | |
75 | | fn list_installed_toolchains(&self) -> Result<String> { |
76 | | let output = std::process::Command::new("rustup") |
77 | | .args(["toolchain", "list"]) |
78 | | .output() |
79 | | .context("failed to run `rustup toolchain list`")?; |
80 | | Ok(String::from_utf8_lossy(&output.stdout).into_owned()) |
81 | | } |
82 | | |
83 | | fn install_toolchain(&self, toolchain: &str) -> Result<()> { |
84 | | let status = std::process::Command::new("rustup") |
85 | | .args([ |
86 | | "toolchain", |
87 | | "install", |
88 | | toolchain, |
89 | | "--component", |
90 | | "llvm-tools", |
91 | | ]) |
92 | | .status() |
93 | | .context("failed to run `rustup toolchain install`")?; |
94 | | if !status.success() { |
95 | | bail!("`rustup toolchain install {toolchain}` failed with status {status}"); |
96 | | } |
97 | | Ok(()) |
98 | | } |
99 | | |
100 | | fn run_cargo_llvm_cov(&self, toolchain: &str, args: &[String]) -> Result<()> { |
101 | | let toolchain_arg = format!("+{toolchain}"); |
102 | | let status = std::process::Command::new("cargo") |
103 | | .arg(&toolchain_arg) |
104 | | .arg("llvm-cov") |
105 | | .args(args) |
106 | | .status() |
107 | | .with_context(|| { |
108 | | format!( |
109 | | "failed to run `cargo {toolchain_arg} llvm-cov {}`", |
110 | | args.join(" ") |
111 | | ) |
112 | | })?; |
113 | | if !status.success() { |
114 | | bail!( |
115 | | "`cargo {toolchain_arg} llvm-cov {}` failed with status {status}", |
116 | | args.join(" ") |
117 | | ); |
118 | | } |
119 | | Ok(()) |
120 | | } |
121 | | |
122 | | fn read_ignore_regex_file(&self) -> Result<String> { |
123 | | std::fs::read_to_string(".config/coverage/ignore-filename.regex") |
124 | | .context("failed to read .config/coverage/ignore-filename.regex") |
125 | | .map(|s| s.trim().to_owned()) |
126 | | } |
127 | | } |
128 | | |
129 | | /// Convert a slice of string literals to a `Vec<String>`. |
130 | 13 | fn args(values: &[&str]) -> Vec<String> { |
131 | 59 | values13 .iter13 ().map13 (|s| (*s).to_owned()).collect13 () |
132 | 13 | } |
133 | | |
134 | | /// Generate coverage reports using the pinned nightly toolchain. |
135 | | /// |
136 | | /// Reads the toolchain identifier from |
137 | | /// `.config/coverage/nightly-toolchain.version`, ensures it is installed, |
138 | | /// cleans stale coverage data, runs the test suite with instrumentation, |
139 | | /// and produces Cobertura XML and HTML reports. |
140 | | /// |
141 | | /// # Arguments |
142 | | /// |
143 | | /// * `system` - Injected I/O provider. |
144 | | /// |
145 | | /// # Errors |
146 | | /// |
147 | | /// Returns an error if any step fails (missing version file, toolchain |
148 | | /// install failure, test failure, or report generation failure). |
149 | 6 | pub fn run_coverage<S: CoverageSystem>(system: &S) -> Result<()> { |
150 | 6 | let toolchain5 = system.read_nightly_version_file()?1 ; |
151 | 5 | log::info!("Using nightly toolchain: {toolchain}"); |
152 | 5 | let ignore_regex = system.read_ignore_regex_file()?0 ; |
153 | | |
154 | | // Ensure toolchain is installed. |
155 | 5 | let installed = system.list_installed_toolchains()?0 ; |
156 | 5 | if installed.lines().any(|line| line.starts_with(&toolchain)) { |
157 | 3 | log::info!("Toolchain already installed"); |
158 | | } else { |
159 | 2 | log::info!("Installing toolchain: {toolchain}"); |
160 | 2 | system.install_toolchain(&toolchain)?1 ; |
161 | | } |
162 | | |
163 | | // Clean previous coverage data. |
164 | 4 | log::info!("Cleaning previous coverage data"); |
165 | 4 | system.run_cargo_llvm_cov(&toolchain, &args(&["clean", "--workspace"]))?1 ; |
166 | | |
167 | | // Run tests with coverage instrumentation. |
168 | 3 | log::info!("Running tests with coverage"); |
169 | 3 | system.run_cargo_llvm_cov( |
170 | 3 | &toolchain, |
171 | 3 | &args(&[ |
172 | 3 | "--all-features", |
173 | 3 | "--workspace", |
174 | 3 | "--no-report", |
175 | 3 | "--", |
176 | 3 | "--no-capture", |
177 | 3 | ]), |
178 | 0 | )?; |
179 | | |
180 | | // Generate Cobertura XML report. |
181 | 3 | log::info!("Generating Cobertura XML report"); |
182 | 3 | system.run_cargo_llvm_cov( |
183 | 3 | &toolchain, |
184 | 3 | &args(&[ |
185 | 3 | "report", |
186 | 3 | "--cobertura", |
187 | 3 | "--output-path", |
188 | 3 | "coverage.xml", |
189 | 3 | "--ignore-filename-regex", |
190 | 3 | &ignore_regex, |
191 | 3 | ]), |
192 | 0 | )?; |
193 | | |
194 | | // Generate HTML report. |
195 | 3 | log::info!("Generating HTML report"); |
196 | 3 | system.run_cargo_llvm_cov( |
197 | 3 | &toolchain, |
198 | 3 | &args(&[ |
199 | 3 | "report", |
200 | 3 | "--html", |
201 | 3 | "--output-dir", |
202 | 3 | "coverage_html", |
203 | 3 | "--ignore-filename-regex", |
204 | 3 | &ignore_regex, |
205 | 3 | ]), |
206 | 0 | )?; |
207 | | |
208 | 3 | log::info!("Coverage reports generated:"); |
209 | 3 | log::info!(" XML: coverage.xml"); |
210 | 3 | log::info!(" HTML: coverage_html/index.html"); |
211 | 3 | Ok(()) |
212 | 6 | } |
213 | | |
214 | | #[cfg(test)] |
215 | | #[path = "tests/test_coverage.rs"] |
216 | | mod tests; |